Rust is a Half-Baked Language
Rust is my programming language of choice for almost all projects I am working on and has been for about three years. This however, doesn't mean I like it. So, while I wait for the situation regarding many of the new systems languages (such as Odin, Zig, or Jai) to settle before making a decision about where I want to invest my time and energy long term, I decided I would take the opportunity to vent about a particular style of problem that comes up frequently in Rust. In particular, many of the language's features feel half-baked, incomplete, or poorly thought out.
There are many open design questions or incomplete features that feel like the kind of thing one would expect in a pre 1.0.0
language which is still finding its feet.
To be clear, these are not in my opinion the biggest issue with Rust as a language, and my explaining of them will give no indication as to why I choose to use Rust anyway. However I think drawing more attention to these can help with the design of future languages to learn from the mistakes of a post 1.0.0
language that still feels strangely unfinished.
As I am writing this, I am just trying to get out some of the things that have been most frustrating to me recently, but I will add more sections to this article as things arise in my day to day life that cause me frustration.
Course Grained Lifetime Management
This is a bit of an overarching category of issues, where Rust is simply not keeping track of enough information with respect to lifetime management.
Tracking References to Individual Fields
Consider the following Rust program.
struct Foo {
a: i8,
b: u8,
}
impl Foo {
fn get_a_mut(&mut self) -> &mut i8 {
&mut self.a
}
}
fn main() {
let mut foo = Foo { a: 0, b: 0 };
let a = foo.get_a_mut();
let b = &mut foo.b;
println!("{}", a);
println!("{}", b);
}
This code fails to compile, with the following error.
error[E0499]: cannot borrow `foo.b` as mutable more than once at a time
--> src/main.rs:15:13
|
14 | let a = foo.get_a_mut();
| --- first mutable borrow occurs here
15 | let b = &mut foo.b;
| ^^^^^^^^^^ second mutable borrow occurs here
16 | println!("{}", a);
| - first borrow later used here
If we apply remove the call to get_a_mut
and instead replace that line with what the function is actually doing (i.e. manually inline the function), we get a working program.
struct Foo {
a: i8,
b: u8,
}
impl Foo {
fn get_a_mut(&mut self) -> &mut i8 {
&mut self.a
}
}
fn main() {
let mut foo = Foo { a: 0, b: 0 };
let a = &mut foo.a;
let b = &mut foo.b;
println!("{}", a);
println!("{}", b);
}
In the fixed example, the following two lines of code
let a = &mut foo.a;
let b = &mut foo.b;
are able to be identified as referring to different fields of the struct, and thus the mutable references do not overlap, much like the behaviour of
let Foo {
a, b,
} = &mut foo;
So what is going on here? It would appear as though once we do the operation within the function, this extra lifetime information is lost. That is, the lifetime information given by the type signature of the function
fn get_a_mut(&mut self) -> &mut i8;
does not include within it the fact that the returned reference only refers to the field a
within self
.
While this example is somewhat contrived, a real world scenario where I have run into this problem is when using a field of type Option<T>
and wishing to return a value of type &mut T
, by performing a form of unwrapping with specific behaviour for how to handle the None
case. For this reason, it actually makes sense to give it its own function rather than just perform the operation inline.
Internal Mutation Before Returning Immutable Reference
Keeping the example similar to the previous, now we have both fields of type u8
, and the function we are examining, get_a_and_update_b
returns an immutable reference to a
, after having set b
to have its value.
struct Foo {
a: u8,
b: u8,
}
impl Foo {
fn get_a_and_update_b(&mut self) -> &u8 {
self.b = self.a;
&self.a
}
}
fn main() {
let mut foo = Foo { a: 0, b: 0 };
let a = foo.get_a_and_update_b();
let b = &foo.b;
println!("{}", a);
println!("{}", b);
}
Once again, this code does not compile. This is because, even though get_a_and_update_b
is done mutating foo
, and there is no mutable reference left to be found, the compiler still considers a
to be the first mutable borrow which interferes with the immutable borrow of b
. The particular error given by the compiler is shown below.
error[E0502]: cannot borrow `foo.b` as immutable because it is also borrowed as mutable
--> src/main.rs:16:13
|
15 | let a = foo.get_a_and_update_b();
| --- mutable borrow occurs here
16 | let b = &foo.b;
| ^^^^^^ immutable borrow occurs here
17 | println!("{}", a);
| - mutable borrow later used here
The compiler clearly states the use of a
is a mutable borrow
being used, but a
has type &u8
.
Most recently this occured for me when working on an abstraction on top of a file of markup which looked somewhat like the following.
struct File {
ast: Ast,
content: String,
path: std::path::PathBuf,
}
I wanted a method on File
with a signature as follows.
fn update(&mut self, new: String) -> &Ast;
This function should replace the content
with new
, reparsing the file and storing it in ast
, and then returning a reference to the updated Ast
. However now this reference to ast
is treated like a mutable reference, even though it isn't, meaning I cannot read the path
and then access the reference I have to the ast
.
Self References Which Aren't
This is somewhat a symptom of the first problem, and the way that the bottom level abstractions on memory allocation work. Consider the following simple function.
fn reference(array: Vec<u8>) -> (Vec<u8>, &u8) {
let first = &array[0];
(array, first)
}
This code will not compile, and gives the following error (among others, although this one is the most insightful).
error[E0515]: cannot return value referencing function parameter `array`
--> src/main.rs:4:5
|
3 | let first = &array[0];
| ----- `array` is borrowed here
4 | (array, first)
| ^^^^^^^^^^^^^^ returns a value referencing data owned by the current function
Of course, Rust is trying to prevent dangling references; we cannot reference something owned by the local function, because that thing will be dropped at the end of scope and the reference will be invalid. In cases where the value is not dropped but rather moved, like the above, moving still results in the new data being in a different memory location, as the return value will be copied to a higher stack frame after returning.
However, in this case the reference is not to the data on the stack held by the struct Vec
, it is data on the heap that most certainly does not move when returning.
Again, a contrived example, however I don't think it's a stretch to imagine that, as in the previous example, one may want to store references to substrings of a file's content on the AST, and doing so makes it impossible to store the two in a struct together.
Const Associated Items in Traits
In a trait, you can create an associated type as a way to associate a particular type to each implementation, which the trait is not generic over. This is used for the output type in the Index
trait for instance.
With the introduction of const
, one would hope that you could do the same with a const
item, such as a usize
. This is fine, but then that value cannot be referred to in the type signature of other functions. As such, the following does not compile.
trait Codec {
const N: usize;
fn encode(self) -> [u8; Self::N];
fn decode(encoded: [u8; Self::N]) -> Self;
}
For Loops (and similar) in Const
const
is an extremely half-baked feature in general. For instance, for
loops cannot be used within const
environments, so the following will not compile.
const TOTAL: u32 = {
let mut total = 0;
for i in 0..10 {
total += 1;
}
total
};
This is despite the fact that the while
loop equivalent does.
const TOTAL: u32 = {
let mut total = 0;
let mut i = 0;
while i < 10 {
total += 1;
i += 1;
}
total
};
This is a consequence of the use of an iterator in the for loop, and the fact that the iterator functions are not const
. However, one cannot mark a function in a trait as const
under any circumstances, either in the implementation or definition. In the case of other function colouring, such as async
and unsafe
, if the trait definition has the modifier, so too must the implementation. The colouring is much like a part of the type signature, and thinking about it like this, normal functions are a subtype of unsafe functions. This works in for unsafe in general, with the following compiling just fine.
fn main() {
a(x)
}
fn x() { }
fn a(_: unsafe fn() -> ()) { }
But none of this works properly for const
.
if let _ and _
if let
is a half-baked feature due to the lack of support for any additional boolean conditions. This makes the use of the keyword if
really confusing. For instance, the following code does not compile.
let mut map = std::collections::HashMap::from(
[('a', 0), ('b', 1), ('c', 2),]
);
if let Some(value) = map.get(&'a') && map.len() == 2 {
println!("Hello");
}